웹 접근성을 위한 키보드 포커스 설계
웹 접근성을 위한 키보드 포커스 설계
키보드 접근성은 모든 요소에 tabindex를 붙이는 일이 아니다. 조작 가능한 요소만 논리적인 순서로 포커스를 받고, 현재 위치가 분명히 보이며, UI가 열리고 닫힐 때 포커스가 예측 가능한 곳으로 이동하는 것이 핵심이다. 기본 HTML 동작을 우선 사용하고, 모달이나 복합 위젯처럼 필요한 곳에만 포커스 이동 로직을 추가한다.
마우스로 확인할 때는 완성되어 보이는 화면도 Tab 키만 사용하면 전혀 다른 제품처럼 느껴질 수 있다. 포커스 표시가 사라지거나, 화면 오른쪽 버튼에서 갑자기 헤더로 이동하거나, 모달 뒤의 링크에 포커스가 빠져나가기도 한다.
이 문제는 “outline 하나 추가하기”로 끝나지 않는다. 포커스에는 최소 세 가지 설계가 함께 필요하다.
- 어떤 요소가 포커스를 받을 것인가?
- 어떤 순서로 이동할 것인가?
- 동적인 화면 변화 뒤 어디에 포커스를 둘 것인가?
목차
- #포커스는 키보드 사용자의 현재 위치다
- #기본 HTML 요소를 먼저 사용한다
- #DOM 순서가 곧 기본 포커스 순서다
- #tabindex를 안전하게 사용하는 법
- #보이는 포커스 스타일 만들기
- #모달의 포커스 수명 주기
- #동적 콘텐츠와 SPA 라우팅 후 포커스
- #복합 위젯은 별도의 키보드 모델이 필요하다
- #자동화만으로 찾을 수 없는 문제
- #실무 테스트 시나리오
- #마무리
- #관련 노트
- #참고 자료
포커스는 키보드 사용자의 현재 위치다
마우스 포인터는 화면 위에 계속 보인다. 반면 키보드 사용자는 포커스 표시를 통해 다음 Enter나 Space 입력이 어느 요소에 적용될지 판단한다. 포커스 표시가 없으면 조작 위치를 잃는다.
flowchart LR
A["Tab 입력"] --> B["다음 조작 요소로 이동"]
B --> C["포커스 표시로 위치 확인"]
C --> D["Enter / Space / 방향키로 조작"]
D --> E["결과 뒤의 포커스 위치 확인"]여기에는 시각적 표시뿐 아니라 문맥이 포함된다. 화면 확대를 사용하는 사람은 페이지 일부만 보기 때문에 포커스가 멀리 점프하면 주변 설명을 놓치기 쉽다. 스크린 리더 사용자는 DOM의 의미 구조와 포커스 순서로 화면을 이해한다.
따라서 좋은 포커스 흐름은 다음 조건을 만족한다.
- 현재 포커스가 눈에 보인다.
- 이동 순서가 콘텐츠의 의미와 작업 순서를 따른다.
- 포커스가 화면 고정 헤더나 팝업 아래에 완전히 가려지지 않는다.
- 비활성 요소나 장식 요소에 불필요하게 멈추지 않는다.
- 화면 변화 뒤에도 다음 행동을 예측할 수 있다.
기본 HTML 요소를 먼저 사용한다
클릭 가능한 카드라는 이유로 <div>에 onClick만 붙이면 마우스로는 동작하지만 기본적으로 Tab 순서에 들어오지 않고 버튼 키보드 동작도 제공하지 않는다.
// 좋지 않은 예
<div className="save-button" onClick={save}>
저장
</div>
버튼 동작이라면 <button>을 사용한다.
<button type="button" className="save-button" onClick={save}>
저장
</button>
기본 요소는 이미 많은 동작을 제공한다.
| 목적 | 우선 사용할 요소 | 기본 키보드 동작 |
|---|---|---|
| 현재 화면에서 동작 실행 | <button> |
Tab 이동, Enter·Space 실행 |
| 다른 위치로 이동 | <a href> |
Tab 이동, Enter 실행 |
| 값 입력 | <input>, <select>, <textarea> |
플랫폼에 맞는 입력과 포커스 |
| 펼침·접힘 | <details>, <summary> |
기본 토글과 상태 |
| 모달 | <dialog> 또는 검증된 dialog 구현 |
구현에 따라 포커스 관리 필요 |
role="button"만 붙인 <div>는 버튼처럼 읽히게 할 뿐 동작을 전부 만들어 주지 않는다. 포커스 가능 여부, Enter와 Space 처리, disabled 상태를 직접 구현해야 한다.
// 불가피하게 커스텀 요소를 쓴다면 필요한 동작이 늘어난다.
<div
role="button"
tabIndex={0}
onClick={save}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
save();
}
}}
>
저장
</div>
이 코드가 필요해졌다면 먼저 실제 <button>으로 바꿀 수 없는 이유를 다시 확인하는 편이 낫다.
DOM 순서가 곧 기본 포커스 순서다
양의 tabindex를 사용하지 않으면 순차 포커스는 대체로 DOM 순서를 따른다. CSS Grid의 order나 시각적 배치만 바꾸면 화면 순서와 키보드 순서가 달라질 수 있다.
<main className="layout">
<section className="content">본문 링크들</section>
<aside className="sidebar">필터 버튼들</aside>
</main>
.layout {
display: grid;
grid-template-columns: 18rem 1fr;
}
.sidebar {
grid-column: 1;
}
.content {
grid-column: 2;
}
시각적으로는 필터가 왼쪽에 있지만 DOM에는 본문이 먼저다. Tab을 누르면 오른쪽 본문을 모두 지난 뒤 왼쪽 필터로 갈 수 있다. 순서가 작업 의미를 해친다면 CSS가 아니라 DOM 구조를 조정한다.
<main className="layout">
<aside className="sidebar">필터 버튼들</aside>
<section className="content">본문 링크들</section>
</main>
모든 시각 순서와 포커스 순서가 완전히 같아야 하는 것은 아니지만, 사용자가 화면의 관계를 이해하고 조작할 수 있어야 한다. 반응형 레이아웃에서 데스크톱과 모바일 순서가 크게 달라지는 경우도 반드시 키보드로 확인한다.
tabindex="1", tabindex="2"로 화면 순서를 강제로 만들면 새 요소를 추가할 때 전체 숫자를 관리해야 한다. DOM 변화와 반응형 화면에서 쉽게 깨진다.
tabindex를 안전하게 사용하는 법
실무에서 자주 쓰는 값은 0과 -1이다.
| 값 | Tab 순서 포함 | JavaScript focus() 가능 |
대표 용도 |
|---|---|---|---|
0 |
예 | 예 | 본래 focusable하지 않은 커스텀 위젯 |
-1 |
아니오 | 예 | 제목, 오류 요약, 모달 초기 포커스 |
| 양수 | 예, 숫자 우선 | 예 | 일반적으로 피함 |
페이지 상단 오류 요약으로 포커스를 옮기는 예를 보자.
import { useEffect, useRef } from "react";
export function ErrorSummary({
messages,
}: {
messages: string[];
}) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (messages.length > 0) {
headingRef.current?.focus();
}
}, [messages.length]);
if (messages.length === 0) return null;
return (
<section aria-labelledby="error-title">
<h2 id="error-title" ref={headingRef} tabIndex={-1}>
입력 내용을 확인해 주세요
</h2>
<ul>
{messages.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
);
}
제목은 Tab을 누를 때마다 멈출 필요는 없지만 제출 실패 직후 프로그램으로 포커스를 받을 필요가 있다. 이럴 때 -1이 적합하다.
비활성 버튼을 Tab 순서에서 임의로 제거하기보다 네이티브 disabled 동작과 사용자가 상태를 이해할 방법을 함께 고려한다. disabled 이유를 알아야 한다면 설명을 가까이 두거나, 경우에 따라 활성 버튼을 유지하고 실행 시 검증 메시지를 제공하는 편이 나을 수 있다.
보이는 포커스 스타일 만들기
디자인 초기화 코드에서 다음 규칙을 넣으면 기본 포커스 표시가 사라진다.
*:focus {
outline: none;
}
대체 스타일 없이 outline을 제거하면 안 된다. 키보드 포커스를 주로 표시하려면 :focus-visible을 사용할 수 있다.
:where(
a,
button,
input,
select,
textarea,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 3px;
}
두 가지 배경 위에서 모두 보여야 한다면 이중 색상 효과를 사용할 수 있다.
.action:focus-visible {
outline: 2px solid #ffffff;
box-shadow: 0 0 0 5px #1d4ed8;
}
다음 사항도 확인한다.
- 기본·hover·active 상태와 포커스 표시가 구분되는가?
- 고대비 모드에서 표시가 사라지지 않는가?
overflow: hidden때문에 outline이 잘리지 않는가?- sticky header, cookie banner, toast에 포커스가 완전히 가려지지 않는가?
- 포커스 표시가 애니메이션 뒤 잠깐 나타났다 사라지지 않는가?
outline 대신 배경색만 바꾸면 색 대비가 부족하거나 고대비 설정에서 차이가 사라질 수 있다. 브라우저 기본 outline을 유지하는 것도 좋은 선택이다.
모달의 포커스 수명 주기
모달은 “안에서 Tab이 순환한다” 한 줄로 끝나지 않는다. 열기 전, 열린 동안, 닫은 뒤의 전체 수명 주기를 설계해야 한다.
stateDiagram-v2
[*] --> Trigger: 열기 버튼에 포커스
Trigger --> Dialog: 모달 열림
Dialog --> Inside: 적절한 내부 요소로 포커스
Inside --> Inside: Tab / Shift+Tab 순환
Inside --> Trigger: Escape·취소 후 닫기
Inside --> NextStep: 작업 완료 후 논리적 다음 위치모달이 열릴 때는 보통 내부 첫 조작 요소로 포커스를 옮긴다. 다만 긴 설명을 읽어야 한다면 tabIndex={-1}인 제목에 먼저 두는 편이 낫고, 되돌리기 어려운 삭제 확인이라면 가장 안전한 취소 버튼에 둘 수 있다.
type DeleteDialogProps = {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
};
export function DeleteDialog({
open,
onCancel,
onConfirm,
}: DeleteDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) cancelRef.current?.focus();
}, [open]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
>
<h2 id="delete-title">항목을 삭제할까요?</h2>
<p>삭제한 항목은 복구할 수 없습니다.</p>
<button ref={cancelRef} type="button" onClick={onCancel}>
취소
</button>
<button type="button" onClick={onConfirm}>
삭제
</button>
</div>
);
}
이 예시는 초기 포커스만 보여 준 축약 코드다. 완전한 모달은 다음을 추가로 보장해야 한다.
Tab과Shift+Tab이 모달 밖으로 나가지 않는다.- Escape로 닫을 수 있다.
- 배경 콘텐츠가 pointer와 키보드 모두에서 비활성이다.
- 보이는 닫기 버튼이 존재한다.
- 모달 제목으로 접근 가능한 이름을 제공한다.
- 닫은 뒤 열기 버튼으로 포커스가 돌아간다.
직접 focus trap을 구현하면 포털, 중첩 모달, DOM 변경, focusable 요소가 하나도 없는 경우까지 처리해야 한다. 네이티브 <dialog>나 충분히 검증된 접근성 컴포넌트를 우선 검토한다.
aria-modal="true"는 동작을 만들지 않는다속성만 선언하고 배경을 실제로 조작 가능하게 두면 보조 기술 사용자에게 잘못된 정보를 준다. 시각적 dim 처리, 배경 inert, 포커스 제한이 실제 동작과 일치해야 한다.
모달을 닫은 뒤 trigger가 목록 삭제로 사라졌다면 존재하지 않는 요소로 돌아갈 수 없다. 이때는 다음 항목, 목록 제목, 새로 만들어진 행처럼 작업 흐름상 논리적인 위치를 선택한다.
동적 콘텐츠와 SPA 라우팅 후 포커스
토스트나 검색 결과가 나타났다고 모든 변화에 포커스를 강제로 옮기면 사용자의 입력 흐름을 끊는다. 포커스 이동은 사용자가 다음 작업을 위해 위치를 알아야 할 때 사용한다.
폼 제출 오류
오류 요약 또는 첫 오류 필드로 이동하고, 필드에는 aria-describedby로 설명을 연결한다.
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error">올바른 이메일 형식을 입력해 주세요.</p>
항목 추가
행 추가 다이얼로그를 닫은 뒤 새 행으로 이동하면 사용자가 결과를 확인하고 다음 작업을 이어가기 쉽다. 단순 성공 토스트라면 live region으로 알리고 현재 포커스를 유지하는 편이 낫다.
SPA 라우팅
클라이언트 라우팅은 브라우저의 전체 문서 로드가 아니어서 포커스가 이전 링크에 남을 수 있다. 새 페이지 제목에 프로그램 포커스를 주거나 라우터가 제공하는 접근성 동작을 확인한다.
export function PageHeading({ children }: { children: string }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
ref.current?.focus();
}, [children]);
return (
<h1 ref={ref} tabIndex={-1}>
{children}
</h1>
);
}
라우트마다 무조건 Effect로 포커스를 옮기기 전에 프레임워크가 이미 스크롤·포커스를 관리하는지 확인한다. 중복 이동은 오히려 예측을 깨뜨릴 수 있다.
복합 위젯은 별도의 키보드 모델이 필요하다
메뉴, 탭, 트리, 그리드 같은 복합 위젯은 모든 내부 항목을 Tab 순서에 넣기보다 하나의 Tab 정지점과 방향키 탐색을 사용하는 경우가 많다.
예를 들어 탭 인터페이스는 보통 다음과 같이 동작한다.
| 키 | 동작 |
|---|---|
| Tab | 탭 그룹에 진입하거나 다음 컴포넌트로 이동 |
| Left / Right | 이전·다음 탭으로 이동 |
| Home / End | 첫·마지막 탭으로 이동 |
| Enter / Space | 수동 활성화 방식이면 탭 선택 |
이를 roving tabindex로 구현할 수 있다.
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
tabIndex={index === activeIndex ? 0 : -1}
aria-selected={index === activeIndex}
onKeyDown={(event) => handleTabKey(event, index)}
>
{tab.label}
</button>
))}
복합 위젯마다 기대되는 키보드 규칙이 다르므로 임의의 키 조합을 만들지 않는다. WAI-ARIA Authoring Practices의 해당 패턴을 참고하고 실제 보조 기술로 검증한다.
자동화만으로 찾을 수 없는 문제
axe, Lighthouse 같은 도구는 focusable하지 않은 클릭 요소나 대비 문제 일부를 찾아 준다. 하지만 포커스 순서가 업무 흐름에 맞는지, 모달을 닫은 뒤 위치가 자연스러운지는 문맥을 알아야 판단할 수 있다.
자동 검사가 놓치기 쉬운 문제는 다음과 같다.
- Tab 순서는 기술적으로 유효하지만 시각적 순서와 동떨어짐
- 포커스 표시가 존재하지만 sticky 요소 뒤에 가려짐
- 모달 닫기 뒤 포커스가
<body>로 사라짐 - 포커스가 이동했지만 스크린 리더에 새 문맥이 충분히 전달되지 않음
- 커스텀 위젯의 방향키 모델이 일반적인 기대와 다름
- hover로만 표시되는 버튼을 키보드 사용자가 발견할 수 없음
따라서 자동 검사와 수동 키보드 탐색을 둘 다 수행한다.
실무 테스트 시나리오
마우스를 치운 뒤 다음 순서로 테스트한다.
- 주소창에서 페이지를 새로 열고 Tab으로 본문에 진입한다.
- 모든 조작 요소에 보이는 포커스가 있는지 확인한다.
- 순서가 화면과 작업 흐름을 따르는지 확인한다.
- Shift+Tab으로 역방향 이동도 확인한다.
- 버튼은 Enter와 Space, 링크는 Enter로 실행한다.
- 메뉴·탭·그리드는 문서화된 방향키로 조작한다.
- 모달을 열고 포커스 진입, 순환, Escape, 닫은 뒤 복귀를 확인한다.
- 폼 오류, 항목 추가·삭제, 라우트 이동 뒤 포커스를 확인한다.
- 화면을 확대하고 sticky UI가 포커스를 가리지 않는지 본다.
- 가능하면 스크린 리더를 켜고 이름·역할·상태를 확인한다.
마무리
키보드 포커스는 CSS 상태 하나가 아니라 인터페이스의 이동 경로다. 기본 HTML 요소를 사용하면 브라우저가 제공하는 포커스와 키보드 동작에서 시작할 수 있다. DOM 순서를 의미 있는 작업 순서로 유지하면 복잡한 tabindex 관리도 대부분 필요하지 않다.
동적인 UI에서는 포커스의 수명 주기를 설계해야 한다. 모달을 열 때 안으로 이동하고, 열린 동안 밖으로 빠져나가지 않게 하며, 닫은 뒤에는 사용자가 출발한 곳이나 다음 작업 위치로 돌려보낸다. 폼 오류와 SPA 라우팅에서도 사용자가 새 문맥을 알아야 하는지 기준으로 이동 여부를 정한다.
좋은 포커스 설계는 Tab 키로 모든 곳에 갈 수 있게 만드는 데서 끝나지 않는다. 사용자가 지금 어디에 있고, 다음 입력이 무엇을 하며, 화면 변화 뒤 어디에서 계속해야 하는지 예측할 수 있게 만드는 것이다.
관련 노트
- 03. Hover Focus 상태 스타일링과 접근성
- 09. 웹 접근성 실무 체크리스트
- React 렌더링과 재렌더링의 차이
- 에러 바운더리가 잡지 못하는 오류
- SSR Hydration 불일치가 생기는 이유